暫無描述

[id].tsx 22KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
  2. import {
  3. Alert,
  4. Image,
  5. KeyboardAvoidingView,
  6. Modal,
  7. Platform,
  8. Pressable,
  9. ScrollView,
  10. StyleSheet,
  11. TextInput,
  12. View,
  13. } from 'react-native';
  14. import * as ImagePicker from 'expo-image-picker';
  15. import DateTimePicker from '@react-native-community/datetimepicker';
  16. import { ResizeMode, Video } from 'expo-av';
  17. import { useLocalSearchParams, useRouter } from 'expo-router';
  18. import { IconSymbol } from '@/components/ui/icon-symbol';
  19. import { useNavigation } from '@react-navigation/native';
  20. import { ThemedButton } from '@/components/themed-button';
  21. import { IconButton } from '@/components/icon-button';
  22. import { ThemedText } from '@/components/themed-text';
  23. import { ThemedView } from '@/components/themed-view';
  24. import { ZoomImageModal } from '@/components/zoom-image-modal';
  25. import { Colors } from '@/constants/theme';
  26. import { useColorScheme } from '@/hooks/use-color-scheme';
  27. import { useTranslation } from '@/localization/i18n';
  28. import { dbPromise, initCoreTables } from '@/services/db';
  29. type FieldRow = {
  30. id: number;
  31. name: string | null;
  32. };
  33. type CropRow = {
  34. id: number;
  35. crop_name: string | null;
  36. };
  37. type HarvestRow = {
  38. id: number;
  39. field_id: number | null;
  40. crop_id: number | null;
  41. harvested_at: string | null;
  42. quantity: number | null;
  43. unit: string | null;
  44. notes: string | null;
  45. photo_uri: string | null;
  46. };
  47. type MediaRow = {
  48. uri: string | null;
  49. };
  50. export default function HarvestDetailScreen() {
  51. const { t } = useTranslation();
  52. const router = useRouter();
  53. const navigation = useNavigation();
  54. const { id } = useLocalSearchParams<{ id?: string | string[] }>();
  55. const harvestId = Number(Array.isArray(id) ? id[0] : id);
  56. const theme = useColorScheme() ?? 'light';
  57. const palette = Colors[theme];
  58. useLayoutEffect(() => {
  59. navigation.setOptions({
  60. headerBackTitleVisible: false,
  61. headerBackTitle: '',
  62. headerBackTitleStyle: { display: 'none' },
  63. headerLeft: () => (
  64. <Pressable onPress={() => router.back()} style={{ paddingHorizontal: 8 }}>
  65. <IconSymbol name="chevron.left" size={20} color={palette.text} />
  66. </Pressable>
  67. ),
  68. });
  69. }, [navigation, palette.text, router]);
  70. const [loading, setLoading] = useState(true);
  71. const [status, setStatus] = useState('');
  72. const [fields, setFields] = useState<FieldRow[]>([]);
  73. const [crops, setCrops] = useState<CropRow[]>([]);
  74. const [fieldModalOpen, setFieldModalOpen] = useState(false);
  75. const [cropModalOpen, setCropModalOpen] = useState(false);
  76. const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
  77. const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
  78. const [harvestDate, setHarvestDate] = useState('');
  79. const [showHarvestPicker, setShowHarvestPicker] = useState(false);
  80. const [quantity, setQuantity] = useState('');
  81. const [unit, setUnit] = useState('');
  82. const [notes, setNotes] = useState('');
  83. const [mediaUris, setMediaUris] = useState<string[]>([]);
  84. const [activeUri, setActiveUri] = useState<string | null>(null);
  85. const [errors, setErrors] = useState<{ field?: string; crop?: string; quantity?: string }>({});
  86. const [zoomUri, setZoomUri] = useState<string | null>(null);
  87. const [saving, setSaving] = useState(false);
  88. const [showSaved, setShowSaved] = useState(false);
  89. const unitPresets = [
  90. { key: 'kg', label: 'kg' },
  91. { key: 'g', label: 'g' },
  92. { key: 'ton', label: 'ton' },
  93. { key: 'pcs', label: 'pcs' },
  94. ];
  95. useEffect(() => {
  96. let isActive = true;
  97. async function loadHarvest() {
  98. try {
  99. await initCoreTables();
  100. const db = await dbPromise;
  101. const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
  102. const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
  103. const rows = await db.getAllAsync<HarvestRow>(
  104. 'SELECT id, field_id, crop_id, harvested_at, quantity, unit, notes, photo_uri FROM harvests WHERE id = ? LIMIT 1;',
  105. harvestId
  106. );
  107. if (!isActive) return;
  108. setFields(fieldRows);
  109. setCrops(cropRows);
  110. const harvest = rows[0];
  111. if (!harvest) {
  112. setStatus(t('harvests.empty'));
  113. setLoading(false);
  114. return;
  115. }
  116. setSelectedFieldId(harvest.field_id ?? null);
  117. setSelectedCropId(harvest.crop_id ?? null);
  118. setHarvestDate(harvest.harvested_at ?? '');
  119. setQuantity(harvest.quantity !== null ? String(harvest.quantity) : '');
  120. setUnit(harvest.unit ?? '');
  121. setNotes(harvest.notes ?? '');
  122. const mediaRows = await db.getAllAsync<MediaRow>(
  123. 'SELECT uri FROM harvest_media WHERE harvest_id = ? ORDER BY created_at ASC;',
  124. harvestId
  125. );
  126. const media = uniqueMediaUris([
  127. ...(mediaRows.map((row) => row.uri).filter(Boolean) as string[]),
  128. ...(normalizeMediaUri(harvest.photo_uri) ? [normalizeMediaUri(harvest.photo_uri) as string] : []),
  129. ]);
  130. setMediaUris(media);
  131. setActiveUri(media[0] ?? normalizeMediaUri(harvest.photo_uri));
  132. } catch (error) {
  133. if (isActive) setStatus(`Error: ${String(error)}`);
  134. } finally {
  135. if (isActive) setLoading(false);
  136. }
  137. }
  138. loadHarvest();
  139. return () => {
  140. isActive = false;
  141. };
  142. }, [harvestId, t]);
  143. const selectedField = useMemo(
  144. () => fields.find((item) => item.id === selectedFieldId),
  145. [fields, selectedFieldId]
  146. );
  147. const selectedCrop = useMemo(
  148. () => crops.find((item) => item.id === selectedCropId),
  149. [crops, selectedCropId]
  150. );
  151. const inputStyle = [
  152. styles.input,
  153. {
  154. borderColor: palette.border,
  155. backgroundColor: palette.input,
  156. color: palette.text,
  157. },
  158. ];
  159. async function handleUpdate() {
  160. const parsedQuantity = quantity.trim() ? Number(quantity) : null;
  161. const nextErrors: { field?: string; crop?: string; quantity?: string } = {};
  162. if (!selectedFieldId) nextErrors.field = t('harvests.fieldRequired');
  163. if (!selectedCropId) nextErrors.crop = t('harvests.cropRequired');
  164. if (quantity.trim() && !Number.isFinite(parsedQuantity)) {
  165. nextErrors.quantity = t('harvests.quantityInvalid');
  166. }
  167. setErrors(nextErrors);
  168. if (Object.keys(nextErrors).length > 0) return;
  169. try {
  170. setSaving(true);
  171. const db = await dbPromise;
  172. const now = new Date().toISOString();
  173. const primaryUri = mediaUris[0] ?? normalizeMediaUri(activeUri);
  174. await db.runAsync(
  175. 'UPDATE harvests SET field_id = ?, crop_id = ?, harvested_at = ?, quantity = ?, unit = ?, notes = ?, photo_uri = ? WHERE id = ?;',
  176. selectedFieldId,
  177. selectedCropId,
  178. harvestDate || null,
  179. parsedQuantity,
  180. unit.trim() || null,
  181. notes.trim() || null,
  182. primaryUri ?? null,
  183. harvestId
  184. );
  185. await db.runAsync('DELETE FROM harvest_media WHERE harvest_id = ?;', harvestId);
  186. const mediaToInsert = uniqueMediaUris([
  187. ...mediaUris,
  188. ...(normalizeMediaUri(activeUri) ? [normalizeMediaUri(activeUri) as string] : []),
  189. ]);
  190. for (const uri of mediaToInsert) {
  191. await db.runAsync(
  192. 'INSERT INTO harvest_media (harvest_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
  193. harvestId,
  194. uri,
  195. isVideoUri(uri) ? 'video' : 'image',
  196. now
  197. );
  198. }
  199. setStatus(t('harvests.saved'));
  200. setShowSaved(true);
  201. setTimeout(() => {
  202. setShowSaved(false);
  203. setStatus('');
  204. }, 1800);
  205. } catch (error) {
  206. setStatus(`Error: ${String(error)}`);
  207. } finally {
  208. setSaving(false);
  209. }
  210. }
  211. function confirmDelete() {
  212. Alert.alert(
  213. t('harvests.deleteTitle'),
  214. t('harvests.deleteMessage'),
  215. [
  216. { text: t('harvests.cancel'), style: 'cancel' },
  217. {
  218. text: t('harvests.delete'),
  219. style: 'destructive',
  220. onPress: async () => {
  221. const db = await dbPromise;
  222. await db.runAsync('DELETE FROM harvest_media WHERE harvest_id = ?;', harvestId);
  223. await db.runAsync('DELETE FROM harvests WHERE id = ?;', harvestId);
  224. router.back();
  225. },
  226. },
  227. ]
  228. );
  229. }
  230. if (loading) {
  231. return (
  232. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  233. <ThemedText>{t('harvests.loading')}</ThemedText>
  234. </ThemedView>
  235. );
  236. }
  237. return (
  238. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  239. <KeyboardAvoidingView
  240. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  241. style={styles.keyboardAvoid}>
  242. <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
  243. <ThemedText type="title">{t('harvests.edit')}</ThemedText>
  244. {status && !showSaved ? <ThemedText>{status}</ThemedText> : null}
  245. <ThemedText>{t('harvests.field')}</ThemedText>
  246. <ThemedButton
  247. title={selectedField?.name || t('harvests.selectField')}
  248. onPress={() => setFieldModalOpen(true)}
  249. variant="secondary"
  250. />
  251. {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
  252. <ThemedText>{t('harvests.crop')}</ThemedText>
  253. <ThemedButton
  254. title={selectedCrop?.crop_name || t('harvests.selectCrop')}
  255. onPress={() => setCropModalOpen(true)}
  256. variant="secondary"
  257. />
  258. {errors.crop ? <ThemedText style={styles.errorText}>{errors.crop}</ThemedText> : null}
  259. <ThemedText>{t('harvests.date')}</ThemedText>
  260. <Pressable onPress={() => setShowHarvestPicker(true)} style={styles.dateInput}>
  261. <ThemedText style={styles.dateValue}>
  262. {harvestDate || t('harvests.datePlaceholder')}
  263. </ThemedText>
  264. </Pressable>
  265. {showHarvestPicker ? (
  266. <DateTimePicker
  267. value={harvestDate ? new Date(harvestDate) : new Date()}
  268. mode="date"
  269. onChange={(event, date) => {
  270. setShowHarvestPicker(false);
  271. if (date) setHarvestDate(toDateOnly(date));
  272. }}
  273. />
  274. ) : null}
  275. <ThemedText>{t('harvests.quantity')}</ThemedText>
  276. <TextInput
  277. value={quantity}
  278. onChangeText={(value) => {
  279. setQuantity(value);
  280. if (errors.quantity) setErrors((prev) => ({ ...prev, quantity: undefined }));
  281. }}
  282. placeholder={t('harvests.quantityPlaceholder')}
  283. placeholderTextColor={palette.placeholder}
  284. style={inputStyle}
  285. keyboardType="decimal-pad"
  286. />
  287. {errors.quantity ? <ThemedText style={styles.errorText}>{errors.quantity}</ThemedText> : null}
  288. <ThemedText>{t('harvests.unit')}</ThemedText>
  289. <View style={styles.unitRow}>
  290. {unitPresets.map((preset) => {
  291. const label = t(`units.${preset.key}`);
  292. const isActive = unit === label || unit === preset.key;
  293. return (
  294. <Pressable
  295. key={`unit-${preset.key}`}
  296. onPress={() => setUnit(label)}
  297. style={[styles.unitChip, isActive ? styles.unitChipActive : null]}>
  298. <ThemedText style={isActive ? styles.unitTextActive : styles.unitText}>{label}</ThemedText>
  299. </Pressable>
  300. );
  301. })}
  302. </View>
  303. <TextInput
  304. value={unit}
  305. onChangeText={setUnit}
  306. placeholder={t('harvests.unitPlaceholder')}
  307. placeholderTextColor={palette.placeholder}
  308. style={inputStyle}
  309. />
  310. <ThemedText>{t('harvests.notes')}</ThemedText>
  311. <TextInput
  312. value={notes}
  313. onChangeText={setNotes}
  314. placeholder={t('harvests.notesPlaceholder')}
  315. placeholderTextColor={palette.placeholder}
  316. style={inputStyle}
  317. multiline
  318. />
  319. <ThemedText>{t('harvests.addMedia')}</ThemedText>
  320. {normalizeMediaUri(activeUri) ? (
  321. isVideoUri(normalizeMediaUri(activeUri) as string) ? (
  322. <Video
  323. source={{ uri: normalizeMediaUri(activeUri) as string }}
  324. style={styles.mediaPreview}
  325. useNativeControls
  326. resizeMode={ResizeMode.CONTAIN}
  327. />
  328. ) : (
  329. <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
  330. <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
  331. </Pressable>
  332. )
  333. ) : (
  334. <ThemedText style={styles.photoPlaceholder}>{t('harvests.noPhoto')}</ThemedText>
  335. )}
  336. {mediaUris.length > 0 ? (
  337. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
  338. {mediaUris.map((uri) => (
  339. <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
  340. {isVideoUri(uri) ? (
  341. <View style={styles.videoThumb}>
  342. <ThemedText style={styles.videoThumbText}>▶</ThemedText>
  343. </View>
  344. ) : (
  345. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  346. )}
  347. <Pressable
  348. style={styles.mediaRemove}
  349. onPress={(event) => {
  350. event.stopPropagation();
  351. setMediaUris((prev) => {
  352. const next = prev.filter((item) => item !== uri);
  353. setActiveUri((current) => (current === uri ? next[0] ?? null : current));
  354. return next;
  355. });
  356. }}>
  357. <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
  358. </Pressable>
  359. </Pressable>
  360. ))}
  361. </ScrollView>
  362. ) : null}
  363. <View style={styles.photoRow}>
  364. <ThemedButton
  365. title={t('harvests.pickFromGallery')}
  366. onPress={() =>
  367. handlePickMedia((uris) => {
  368. if (uris.length === 0) return;
  369. setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  370. setActiveUri((prev) => prev ?? uris[0]);
  371. })
  372. }
  373. variant="secondary"
  374. />
  375. <ThemedButton
  376. title={t('harvests.takeMedia')}
  377. onPress={() =>
  378. handleTakeMedia((uri) => {
  379. if (!uri) return;
  380. setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  381. setActiveUri((prev) => prev ?? uri);
  382. })
  383. }
  384. variant="secondary"
  385. />
  386. </View>
  387. <View style={styles.actions}>
  388. <IconButton
  389. name="trash"
  390. onPress={confirmDelete}
  391. accessibilityLabel={t('harvests.delete')}
  392. variant="danger"
  393. />
  394. <View style={styles.updateGroup}>
  395. {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('harvests.saved')}</ThemedText> : null}
  396. <ThemedButton
  397. title={saving ? t('harvests.saving') : t('harvests.update')}
  398. onPress={handleUpdate}
  399. disabled={saving}
  400. />
  401. </View>
  402. </View>
  403. </ScrollView>
  404. </KeyboardAvoidingView>
  405. <Modal transparent visible={fieldModalOpen} animationType="fade">
  406. <Pressable style={styles.modalBackdrop} onPress={() => setFieldModalOpen(false)}>
  407. <View style={styles.modalCard}>
  408. <ThemedText type="subtitle">{t('harvests.selectField')}</ThemedText>
  409. <ScrollView style={styles.modalList}>
  410. {fields.map((item) => (
  411. <Pressable
  412. key={item.id}
  413. style={styles.modalItem}
  414. onPress={() => {
  415. setSelectedFieldId(item.id);
  416. setFieldModalOpen(false);
  417. }}>
  418. <ThemedText>{item.name || t('harvests.noField')}</ThemedText>
  419. </Pressable>
  420. ))}
  421. </ScrollView>
  422. </View>
  423. </Pressable>
  424. </Modal>
  425. <Modal transparent visible={cropModalOpen} animationType="fade">
  426. <Pressable style={styles.modalBackdrop} onPress={() => setCropModalOpen(false)}>
  427. <View style={styles.modalCard}>
  428. <ThemedText type="subtitle">{t('harvests.selectCrop')}</ThemedText>
  429. <ScrollView style={styles.modalList}>
  430. {crops.map((item) => (
  431. <Pressable
  432. key={item.id}
  433. style={styles.modalItem}
  434. onPress={() => {
  435. setSelectedCropId(item.id);
  436. setCropModalOpen(false);
  437. }}>
  438. <ThemedText>{item.crop_name || t('harvests.noCrop')}</ThemedText>
  439. </Pressable>
  440. ))}
  441. </ScrollView>
  442. </View>
  443. </Pressable>
  444. </Modal>
  445. <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
  446. </ThemedView>
  447. );
  448. }
  449. async function handlePickMedia(onAdd: (uris: string[]) => void) {
  450. const result = await ImagePicker.launchImageLibraryAsync({
  451. mediaTypes: getMediaTypes(),
  452. quality: 1,
  453. allowsMultipleSelection: true,
  454. selectionLimit: 0,
  455. });
  456. if (result.canceled) return;
  457. const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
  458. if (uris.length === 0) return;
  459. onAdd(uris);
  460. }
  461. async function handleTakeMedia(onAdd: (uri: string | null) => void) {
  462. const permission = await ImagePicker.requestCameraPermissionsAsync();
  463. if (!permission.granted) {
  464. return;
  465. }
  466. const result = await ImagePicker.launchCameraAsync({
  467. mediaTypes: getMediaTypes(),
  468. quality: 1,
  469. });
  470. if (result.canceled) return;
  471. const asset = result.assets[0];
  472. onAdd(asset.uri);
  473. }
  474. function getMediaTypes() {
  475. const mediaType = (ImagePicker as {
  476. MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
  477. }).MediaType;
  478. const imageType = mediaType?.Image ?? mediaType?.Images;
  479. const videoType = mediaType?.Video ?? mediaType?.Videos;
  480. if (imageType && videoType) {
  481. return [imageType, videoType];
  482. }
  483. return imageType ?? videoType ?? ['images', 'videos'];
  484. }
  485. function isVideoUri(uri: string) {
  486. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  487. }
  488. function normalizeMediaUri(uri?: string | null) {
  489. if (typeof uri !== 'string') return null;
  490. const trimmed = uri.trim();
  491. return trimmed ? trimmed : null;
  492. }
  493. function uniqueMediaUris(uris: string[]) {
  494. const seen = new Set<string>();
  495. const result: string[] = [];
  496. for (const uri of uris) {
  497. if (!uri || seen.has(uri)) continue;
  498. seen.add(uri);
  499. result.push(uri);
  500. }
  501. return result;
  502. }
  503. function toDateOnly(date: Date) {
  504. return date.toISOString().slice(0, 10);
  505. }
  506. const styles = StyleSheet.create({
  507. container: {
  508. flex: 1,
  509. },
  510. keyboardAvoid: {
  511. flex: 1,
  512. },
  513. content: {
  514. padding: 16,
  515. gap: 10,
  516. paddingBottom: 40,
  517. },
  518. input: {
  519. borderRadius: 10,
  520. borderWidth: 1,
  521. paddingHorizontal: 12,
  522. paddingVertical: 10,
  523. fontSize: 15,
  524. },
  525. errorText: {
  526. color: '#C0392B',
  527. fontSize: 12,
  528. },
  529. dateInput: {
  530. borderRadius: 10,
  531. borderWidth: 1,
  532. borderColor: '#B9B9B9',
  533. paddingHorizontal: 12,
  534. paddingVertical: 10,
  535. },
  536. dateValue: {
  537. opacity: 0.7,
  538. },
  539. mediaPreview: {
  540. width: '100%',
  541. height: 220,
  542. borderRadius: 12,
  543. backgroundColor: '#1C1C1C',
  544. },
  545. photoRow: {
  546. flexDirection: 'row',
  547. gap: 8,
  548. },
  549. actions: {
  550. marginTop: 12,
  551. flexDirection: 'row',
  552. justifyContent: 'space-between',
  553. alignItems: 'center',
  554. gap: 10,
  555. },
  556. photoPlaceholder: {
  557. opacity: 0.6,
  558. },
  559. unitRow: {
  560. flexDirection: 'row',
  561. flexWrap: 'wrap',
  562. gap: 8,
  563. marginBottom: 8,
  564. },
  565. unitChip: {
  566. borderRadius: 999,
  567. borderWidth: 1,
  568. borderColor: '#C6C6C6',
  569. paddingHorizontal: 10,
  570. paddingVertical: 4,
  571. },
  572. unitChipActive: {
  573. borderColor: '#2F7D4F',
  574. backgroundColor: '#E7F3EA',
  575. },
  576. unitText: {
  577. fontSize: 12,
  578. },
  579. unitTextActive: {
  580. fontSize: 12,
  581. color: '#2F7D4F',
  582. fontWeight: '600',
  583. },
  584. mediaStrip: {
  585. marginTop: 6,
  586. },
  587. mediaChip: {
  588. width: 72,
  589. height: 72,
  590. borderRadius: 10,
  591. marginRight: 8,
  592. overflow: 'hidden',
  593. backgroundColor: '#E6E1D4',
  594. alignItems: 'center',
  595. justifyContent: 'center',
  596. },
  597. mediaThumb: {
  598. width: '100%',
  599. height: '100%',
  600. },
  601. videoThumb: {
  602. width: '100%',
  603. height: '100%',
  604. backgroundColor: '#1C1C1C',
  605. alignItems: 'center',
  606. justifyContent: 'center',
  607. },
  608. videoThumbText: {
  609. color: '#FFFFFF',
  610. fontSize: 18,
  611. fontWeight: '700',
  612. },
  613. mediaRemove: {
  614. position: 'absolute',
  615. top: 4,
  616. right: 4,
  617. width: 18,
  618. height: 18,
  619. borderRadius: 9,
  620. backgroundColor: 'rgba(0,0,0,0.6)',
  621. alignItems: 'center',
  622. justifyContent: 'center',
  623. },
  624. mediaRemoveText: {
  625. color: '#FFFFFF',
  626. fontSize: 12,
  627. lineHeight: 14,
  628. fontWeight: '700',
  629. },
  630. updateGroup: {
  631. flexDirection: 'row',
  632. alignItems: 'center',
  633. gap: 8,
  634. },
  635. inlineToastText: {
  636. fontWeight: '700',
  637. fontSize: 12,
  638. },
  639. modalBackdrop: {
  640. flex: 1,
  641. backgroundColor: 'rgba(0,0,0,0.4)',
  642. justifyContent: 'center',
  643. padding: 24,
  644. },
  645. modalCard: {
  646. borderRadius: 14,
  647. backgroundColor: '#FFFFFF',
  648. padding: 16,
  649. gap: 10,
  650. maxHeight: '80%',
  651. },
  652. modalList: {
  653. maxHeight: 300,
  654. },
  655. modalItem: {
  656. paddingVertical: 10,
  657. },
  658. });